@ncds/ui-admin 1.8.22-alpha.2 → 1.8.22

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.
Files changed (58) hide show
  1. package/dist/cjs/assets/scripts/table/scrollbar/const.js +50 -0
  2. package/dist/cjs/assets/scripts/table/scrollbar/drag.js +75 -0
  3. package/dist/cjs/assets/scripts/table/scrollbar/geometry.js +69 -0
  4. package/dist/cjs/assets/scripts/table/scrollbar/isolate.js +36 -0
  5. package/dist/cjs/src/components/data-display/table/Table.js +1 -0
  6. package/dist/cjs/src/components/data-display/table/__tests__/useTableScrollbars.characterisation.test.js +390 -0
  7. package/dist/cjs/src/components/data-display/table/__tests__/useTableScrollbars.regression.test.js +339 -0
  8. package/dist/cjs/src/components/data-display/table/useTableScrollbars.js +115 -86
  9. package/dist/cjs/src/components/feedback-and-status/badge/Badge.js +1 -1
  10. package/dist/cjs/src/components/overlays/modal/Modal.js +46 -41
  11. package/dist/cjs/src/components/overlays/postcode-search-modal/PostcodeSearchModal.js +2 -2
  12. package/dist/cjs/src/generated/scoped-css.js +1 -1
  13. package/dist/esm/assets/scripts/table/scrollbar/const.js +44 -0
  14. package/dist/esm/assets/scripts/table/scrollbar/drag.js +67 -0
  15. package/dist/esm/assets/scripts/table/scrollbar/geometry.js +60 -0
  16. package/dist/esm/assets/scripts/table/scrollbar/isolate.js +27 -0
  17. package/dist/esm/src/components/data-display/table/Table.js +1 -0
  18. package/dist/esm/src/components/data-display/table/__tests__/useTableScrollbars.characterisation.test.js +387 -0
  19. package/dist/esm/src/components/data-display/table/__tests__/useTableScrollbars.regression.test.js +336 -0
  20. package/dist/esm/src/components/data-display/table/useTableScrollbars.js +85 -86
  21. package/dist/esm/src/components/feedback-and-status/badge/Badge.js +1 -1
  22. package/dist/esm/src/components/overlays/modal/Modal.js +47 -42
  23. package/dist/esm/src/components/overlays/postcode-search-modal/PostcodeSearchModal.js +2 -2
  24. package/dist/esm/src/generated/scoped-css.js +1 -1
  25. package/dist/temp/assets/scripts/table/scrollbar/const.d.ts +46 -0
  26. package/dist/temp/assets/scripts/table/scrollbar/const.js +44 -0
  27. package/dist/temp/assets/scripts/table/scrollbar/drag.d.ts +18 -0
  28. package/dist/temp/assets/scripts/table/scrollbar/drag.js +49 -0
  29. package/dist/temp/assets/scripts/table/scrollbar/geometry.d.ts +47 -0
  30. package/dist/temp/assets/scripts/table/scrollbar/geometry.js +37 -0
  31. package/dist/temp/assets/scripts/table/scrollbar/isolate.d.ts +4 -0
  32. package/dist/temp/assets/scripts/table/scrollbar/isolate.js +30 -0
  33. package/dist/temp/src/components/data-display/table/Table.js +1 -0
  34. package/dist/temp/src/components/data-display/table/__tests__/useTableScrollbars.characterisation.test.d.ts +1 -0
  35. package/dist/temp/src/components/data-display/table/__tests__/useTableScrollbars.characterisation.test.js +282 -0
  36. package/dist/temp/src/components/data-display/table/__tests__/useTableScrollbars.regression.test.d.ts +1 -0
  37. package/dist/temp/src/components/data-display/table/__tests__/useTableScrollbars.regression.test.js +244 -0
  38. package/dist/temp/src/components/data-display/table/useTableScrollbars.d.ts +2 -6
  39. package/dist/temp/src/components/data-display/table/useTableScrollbars.js +100 -72
  40. package/dist/temp/src/components/feedback-and-status/badge/Badge.d.ts +1 -1
  41. package/dist/temp/src/components/feedback-and-status/badge/Badge.js +1 -1
  42. package/dist/temp/src/components/overlays/modal/Modal.d.ts +5 -5
  43. package/dist/temp/src/components/overlays/modal/Modal.js +11 -14
  44. package/dist/temp/src/components/overlays/postcode-search-modal/PostcodeSearchModal.js +1 -1
  45. package/dist/temp/src/generated/scoped-css.js +1 -1
  46. package/dist/temp/src/hooks/useIsomorphicLayoutEffect.d.ts +2 -2
  47. package/dist/types/assets/scripts/table/scrollbar/const.d.ts +46 -0
  48. package/dist/types/assets/scripts/table/scrollbar/drag.d.ts +18 -0
  49. package/dist/types/assets/scripts/table/scrollbar/geometry.d.ts +47 -0
  50. package/dist/types/assets/scripts/table/scrollbar/isolate.d.ts +4 -0
  51. package/dist/types/src/components/data-display/table/__tests__/useTableScrollbars.characterisation.test.d.ts +1 -0
  52. package/dist/types/src/components/data-display/table/__tests__/useTableScrollbars.regression.test.d.ts +1 -0
  53. package/dist/types/src/components/data-display/table/useTableScrollbars.d.ts +2 -6
  54. package/dist/types/src/components/feedback-and-status/badge/Badge.d.ts +1 -1
  55. package/dist/types/src/components/overlays/modal/Modal.d.ts +5 -5
  56. package/dist/types/src/hooks/useIsomorphicLayoutEffect.d.ts +2 -2
  57. package/dist/ui-admin/assets/styles/style.css +57 -28
  58. package/package.json +1 -1
@@ -0,0 +1,44 @@
1
+ /** Table 커스텀 스크롤바의 클래스명·속성명·수치 상수. React 와 CDN 이 함께 참조한다. */
2
+ /** `_table.scss` 의 `--ncua-table-header-height` 와 같은 값 */
3
+ export const TABLE_HEADER_HEIGHT = 40;
4
+ /** `.ncua-table__scrollbar-thumb { height }` 와 같은 값 */
5
+ export const SCROLLBAR_THUMB_MIN_HEIGHT = 40;
6
+ /** `.ncua-table__h-scrollbar-thumb { width }` 와 같은 값 */
7
+ export const H_SCROLLBAR_THUMB_MIN_WIDTH = 40;
8
+ /** `.ncua-table__h-scrollbar { left/right: var(--spacing-s) }` 와 같은 값 */
9
+ export const H_SCROLLBAR_SIDE_GAP = 8;
10
+ /** 세로 트랙 상하 여백 합계. 헤더 회피분은 `TABLE_HEADER_HEIGHT` 로 따로 뺀다 */
11
+ export const SCROLLBAR_TRACK_OFFSET = 16;
12
+ export const SCROLLBAR_CLASS_NAMES = {
13
+ table: 'ncua-table',
14
+ tableWrapper: 'ncua-table-wrapper',
15
+ dataGrid: 'ncua-data-grid',
16
+ scrollArea: 'ncua-table__scroll-area',
17
+ scrollContainer: 'ncua-table__scroll-container',
18
+ scrollbar: 'ncua-table__scrollbar',
19
+ scrollbarThumb: 'ncua-table__scrollbar-thumb',
20
+ hScrollContainer: 'ncua-table__h-scroll-container',
21
+ hScrollInner: 'ncua-table__h-scroll-inner',
22
+ hScrollbar: 'ncua-table__h-scrollbar',
23
+ hScrollbarThumb: 'ncua-table__h-scrollbar-thumb',
24
+ tableElement: 'ncua-table__table'
25
+ };
26
+ /** `optOut` 을 제외한 전부가 런타임에만 부착된다. 마크업에 직접 기재하지 않는다. */
27
+ export const SCROLLBAR_ATTRS = {
28
+ ready: 'data-scrollbar-ready',
29
+ /** `ready` 와 항상 함께 부착·제거한다 */
30
+ owner: 'data-scrollbar-owner',
31
+ generated: 'data-scrollbar-generated',
32
+ fallbackArmed: 'data-ncua-scrollbar-fallback',
33
+ dragging: 'data-dragging',
34
+ optOut: 'data-ncua-scrollbar'
35
+ };
36
+ export const SCROLLBAR_OWNERS = {
37
+ cdn: 'cdn',
38
+ react: 'react'
39
+ };
40
+ export const SCROLLBAR_OWNER_VALUES = Object.values(SCROLLBAR_OWNERS);
41
+ export const OPT_OUT_VALUE = 'off';
42
+ export const AXIS_VALUES = ['both', 'x', 'y'];
43
+ export const DEFAULT_AXIS = 'both';
44
+ export const LOG_PREFIX = '[ncua.TableScrollbar]';
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 썸 드래그 와이어링. React 훅과 CDN 바인딩이 공유한다.
3
+ * 모듈 평가 시점에는 `document`·`window` 를 참조하지 않는다 — SSR 경로에서 함께 평가된다.
4
+ */
5
+ import { SCROLLBAR_ATTRS } from './const';
6
+ import { computeDragScrollDelta } from './geometry';
7
+ import { isolate } from './isolate';
8
+ export const startThumbDrag = (event, input) => {
9
+ const {
10
+ axis,
11
+ scrollEl,
12
+ thumbEl,
13
+ draggingTarget,
14
+ measureDragBasisSize,
15
+ releaseSignal
16
+ } = input;
17
+ event.preventDefault();
18
+ const isVertical = axis === 'y';
19
+ // 측정을 속성 부착보다 앞에 둔다. 부착이 스타일을 무효화한 뒤 읽으면 강제 재계산이 1회 붙는다.
20
+ const startPointer = isVertical ? event.clientY : event.clientX;
21
+ const startOffset = isVertical ? scrollEl.scrollTop : scrollEl.scrollLeft;
22
+ const scrollSize = isVertical ? scrollEl.scrollHeight : scrollEl.scrollWidth;
23
+ const clientSize = isVertical ? scrollEl.clientHeight : scrollEl.clientWidth;
24
+ const thumbSize = isVertical ? thumbEl.offsetHeight : thumbEl.offsetWidth;
25
+ const dragBasisSize = measureDragBasisSize(clientSize);
26
+ draggingTarget.setAttribute(SCROLLBAR_ATTRS.dragging, '');
27
+ const dragController = new AbortController();
28
+ const dragSignal = dragController.signal;
29
+ // 리스너 해제를 먼저 끝낸다. 뒤 문장이 실패해도 드래그가 계속 붙어 있지 않게 한다.
30
+ const endDrag = isolate('드래그 종료', () => {
31
+ dragController.abort();
32
+ draggingTarget.removeAttribute(SCROLLBAR_ATTRS.dragging);
33
+ });
34
+ const onMove = isolate('드래그 이동', moveEvent => {
35
+ const delta = computeDragScrollDelta({
36
+ pointerDelta: (isVertical ? moveEvent.clientY : moveEvent.clientX) - startPointer,
37
+ scrollSize,
38
+ clientSize,
39
+ dragBasisSize,
40
+ thumbSize
41
+ });
42
+ if (isVertical) {
43
+ scrollEl.scrollTop = startOffset + delta;
44
+ return;
45
+ }
46
+ scrollEl.scrollLeft = startOffset + delta;
47
+ });
48
+ document.addEventListener('mousemove', onMove, {
49
+ signal: dragSignal
50
+ });
51
+ document.addEventListener('mouseup', endDrag, {
52
+ signal: dragSignal
53
+ });
54
+ // 창 밖 릴리즈·탭 전환으로 mouseup 을 놓치는 경우가 상시 발생한다.
55
+ document.addEventListener('pointercancel', endDrag, {
56
+ signal: dragSignal
57
+ });
58
+ window.addEventListener('blur', endDrag, {
59
+ signal: dragSignal
60
+ });
61
+ // `dragSignal` 을 함께 넘기지 않으면 정상 종료한 드래그의 리스너가 해제 신호에 남아
62
+ // 반복할수록 누적된다.
63
+ releaseSignal?.addEventListener('abort', endDrag, {
64
+ once: true,
65
+ signal: dragSignal
66
+ });
67
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 썸 치수·위치 산출. React 훅과 CDN 바인딩이 공유한다.
3
+ * SSR 경로에서도 평가되므로 `document`·`window` 를 참조하지 않는다.
4
+ */
5
+ const HIDDEN = {
6
+ visible: false,
7
+ size: 0,
8
+ offset: 0
9
+ };
10
+ const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
11
+ export const computeThumbGeometry = metrics => {
12
+ const {
13
+ scrollOffset,
14
+ scrollSize,
15
+ clientSize,
16
+ trackSize,
17
+ thumbMinSize
18
+ } = metrics;
19
+ if (!(scrollSize > clientSize)) return HIDDEN;
20
+ if (!(trackSize > 0)) return HIDDEN;
21
+ const size = Math.min(trackSize, Math.max(thumbMinSize, clientSize / scrollSize * trackSize));
22
+ const travel = trackSize - size;
23
+ if (travel <= 0) return {
24
+ visible: true,
25
+ size,
26
+ offset: 0
27
+ };
28
+ const offset = clamp(scrollOffset / (scrollSize - clientSize) * travel, 0, travel);
29
+ return {
30
+ visible: true,
31
+ size,
32
+ offset
33
+ };
34
+ };
35
+ /** 호출자가 넘겨야 하는 축별 `dragBasisSize` 기준 */
36
+ export const DRAG_BASIS = {
37
+ y: 'clientSize',
38
+ x: 'trackSize'
39
+ };
40
+ /**
41
+ * 포인터 이동량(px) → 스크롤 오프셋 변화량(px).
42
+ * 환산 분모 기준이 축마다 다르므로 `dragBasisSize` 는 호출자가 `DRAG_BASIS` 대로 넘긴다.
43
+ */
44
+ export const computeDragScrollDelta = input => {
45
+ const {
46
+ pointerDelta,
47
+ scrollSize,
48
+ clientSize,
49
+ dragBasisSize,
50
+ thumbSize
51
+ } = input;
52
+ const denominator = dragBasisSize - thumbSize;
53
+ if (denominator <= 0) return 0;
54
+ return pointerDelta * ((scrollSize - clientSize) / denominator);
55
+ };
56
+ /** 가로 트랙은 스크롤 컨테이너 안에 있어 함께 밀리므로, 되돌릴 오프셋과 폭을 산출한다. */
57
+ export const computeHorizontalTrackBox = input => ({
58
+ translateX: input.scrollLeft,
59
+ width: Math.max(0, input.clientWidth - input.sideGap * 2)
60
+ });
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 라이브러리가 스스로 부르는 콜백(리스너·옵저버·정리 루프)을 감싼다.
3
+ * 소비처 페이지로 예외가 새면 그 페이지의 전역 오류 처리기가 발화하므로 여기서 가둔다.
4
+ * 생성자처럼 호출자가 결과를 판정해야 하는 경로에는 쓰지 않는다.
5
+ */
6
+ import { LOG_PREFIX } from './const';
7
+ /** 스크롤·리사이즈는 초당 수십 회 불리므로 같은 지점의 실패는 1회만 기록한다 */
8
+ export const isolate = (label, run) => {
9
+ let reported = false;
10
+ return function () {
11
+ try {
12
+ run(...arguments);
13
+ } catch (error) {
14
+ if (reported) return;
15
+ reported = true;
16
+ console.error(`${LOG_PREFIX} ${label} 중 오류가 발생했습니다. 이후 같은 오류는 기록을 생략합니다.`, error);
17
+ }
18
+ };
19
+ };
20
+ /** 1회성 호출용. 실패해도 나머지 정리·순회가 이어지도록 한다 */
21
+ export const runIsolated = (label, run) => {
22
+ try {
23
+ run();
24
+ } catch (error) {
25
+ console.error(`${LOG_PREFIX} ${label} 중 오류가 발생했습니다.`, error);
26
+ }
27
+ };
@@ -489,6 +489,7 @@ const TableComponent = /*#__PURE__*/forwardRef((_ref17, ref) => {
489
489
  footerContent,
490
490
  paginationContent
491
491
  } = sortChildren(children);
492
+ // maxHeight 를 본문 영역 높이로 해석해 헤더 높이를 가산한다.
492
493
  const scrollStyle = fixedHeader && maxHeight ? {
493
494
  maxHeight: typeof maxHeight === 'number' ? `${maxHeight + TABLE_HEADER_HEIGHT}px` : `calc(${maxHeight} + ${TABLE_HEADER_HEIGHT}px)`
494
495
  } : undefined;
@@ -0,0 +1,387 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * 특성화 테스트. 기대값은 "이래야 한다"가 아니라 "현재 이렇게 동작한다"이며,
4
+ * 산출 로직을 CDN 과 공유한 뒤에도 같은 값이 나오는지 판정하는 기준선이다.
5
+ */
6
+ import { createElement, useRef } from 'react';
7
+ import { createRoot } from 'react-dom/client';
8
+ import { act } from 'react-dom/test-utils';
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { useTableHorizontalScrollbar, useTableVerticalScrollbar } from '../useTableScrollbars';
11
+ const observers = [];
12
+ class ResizeObserverStub {
13
+ constructor(callback) {
14
+ this.record = {
15
+ callback,
16
+ targets: [],
17
+ disconnected: false
18
+ };
19
+ observers.push(this.record);
20
+ }
21
+ observe(target) {
22
+ this.record.targets.push(target);
23
+ }
24
+ unobserve() {
25
+ // 전체 해제만 검증 대상이다.
26
+ }
27
+ disconnect() {
28
+ this.record.disconnected = true;
29
+ }
30
+ }
31
+ /** jsdom 은 레이아웃을 계산하지 않으므로 측정 속성을 직접 정의한다. */
32
+ const defineMetrics = (element, metrics) => {
33
+ for (const [key, value] of Object.entries(metrics)) {
34
+ Object.defineProperty(element, key, {
35
+ configurable: true,
36
+ writable: true,
37
+ value
38
+ });
39
+ }
40
+ };
41
+ let container;
42
+ let root;
43
+ const render = element => {
44
+ act(() => {
45
+ root.render(element);
46
+ });
47
+ };
48
+ const VerticalHarness = _ref => {
49
+ let {
50
+ enabled = true
51
+ } = _ref;
52
+ const scrollContainerRef = useRef(null);
53
+ const scrollAreaRef = useRef(null);
54
+ const scrollbarRef = useRef(null);
55
+ const thumbRef = useRef(null);
56
+ const {
57
+ handleThumbMouseDown
58
+ } = useTableVerticalScrollbar({
59
+ enabled,
60
+ layoutKey: false,
61
+ scrollContainerRef,
62
+ scrollAreaRef,
63
+ scrollbarRef,
64
+ thumbRef
65
+ });
66
+ return /*#__PURE__*/createElement('div', {
67
+ className: 'ncua-table ncua-table--horizontal ncua-table--fixed-header'
68
+ }, /*#__PURE__*/createElement('div', {
69
+ ref: scrollAreaRef,
70
+ className: 'ncua-table__scroll-area'
71
+ }, /*#__PURE__*/createElement('div', {
72
+ ref: scrollContainerRef,
73
+ className: 'ncua-table__scroll-container'
74
+ }, /*#__PURE__*/createElement('table', {
75
+ className: 'ncua-table__table'
76
+ }, /*#__PURE__*/createElement('tbody', null))), /*#__PURE__*/createElement('div', {
77
+ ref: scrollbarRef,
78
+ className: 'ncua-table__scrollbar',
79
+ 'aria-hidden': true
80
+ }, /*#__PURE__*/createElement('div', {
81
+ ref: thumbRef,
82
+ className: 'ncua-table__scrollbar-thumb',
83
+ onMouseDown: handleThumbMouseDown
84
+ }))));
85
+ };
86
+ const HorizontalHarness = _ref2 => {
87
+ let {
88
+ enabled = true
89
+ } = _ref2;
90
+ const hScrollContainerRef = useRef(null);
91
+ const hScrollbarRef = useRef(null);
92
+ const hThumbRef = useRef(null);
93
+ const {
94
+ handleHThumbMouseDown
95
+ } = useTableHorizontalScrollbar({
96
+ enabled,
97
+ hScrollContainerRef,
98
+ hScrollbarRef,
99
+ hThumbRef
100
+ });
101
+ return /*#__PURE__*/createElement('div', {
102
+ className: 'ncua-table ncua-table--horizontal'
103
+ }, /*#__PURE__*/createElement('div', {
104
+ ref: hScrollContainerRef,
105
+ className: 'ncua-table__h-scroll-container'
106
+ }, /*#__PURE__*/createElement('div', {
107
+ className: 'ncua-table__h-scroll-inner'
108
+ }, /*#__PURE__*/createElement('table', {
109
+ className: 'ncua-table__table'
110
+ }, /*#__PURE__*/createElement('tbody', null))), /*#__PURE__*/createElement('div', {
111
+ ref: hScrollbarRef,
112
+ className: 'ncua-table__h-scrollbar',
113
+ 'aria-hidden': true
114
+ }, /*#__PURE__*/createElement('div', {
115
+ ref: hThumbRef,
116
+ className: 'ncua-table__h-scrollbar-thumb',
117
+ onMouseDown: handleHThumbMouseDown
118
+ }))));
119
+ };
120
+ const query = selector => {
121
+ const found = container.querySelector(selector);
122
+ if (!found) throw new Error(`선택자에 해당하는 요소가 없습니다: ${selector}`);
123
+ return found;
124
+ };
125
+ const fireScroll = element => {
126
+ act(() => {
127
+ element.dispatchEvent(new Event('scroll'));
128
+ });
129
+ };
130
+ beforeEach(() => {
131
+ vi.stubGlobal('ResizeObserver', ResizeObserverStub);
132
+ observers.length = 0;
133
+ container = document.createElement('div');
134
+ document.body.appendChild(container);
135
+ act(() => {
136
+ root = createRoot(container);
137
+ });
138
+ });
139
+ afterEach(() => {
140
+ act(() => {
141
+ root.unmount();
142
+ });
143
+ container.remove();
144
+ vi.unstubAllGlobals();
145
+ vi.restoreAllMocks();
146
+ });
147
+ /**
148
+ * 가상 레이아웃 수치. 기대값은 구현 상수를 쓰지 않고 리터럴로 적는다 —
149
+ * 상수를 참조하면 값이 바뀔 때 기대값도 함께 움직여 수치 회귀를 놓친다.
150
+ */
151
+ const LAYOUT = {
152
+ viewportHeight: 200,
153
+ contentHeight: 800,
154
+ shortContentHeight: 400,
155
+ trackHeight: 150,
156
+ thumbHeight: 40,
157
+ scrollTop: 300,
158
+ areaHeight: 240,
159
+ viewportWidth: 600,
160
+ contentWidth: 1500,
161
+ thumbWidth: 234,
162
+ scrollLeft: 120,
163
+ pointerDelta: 10
164
+ };
165
+ /** toBeCloseTo 자릿수 */
166
+ const PRECISION = 5;
167
+ /** (1500 - 600) / (584 - 234) 비율로 10px 을 끌었을 때의 scrollLeft */
168
+ const EXPECTED_HORIZONTAL_SCROLL_LEFT = 25.714285;
169
+ describe('useTableVerticalScrollbar', () => {
170
+ const setUpOverflow = () => {
171
+ render(/*#__PURE__*/createElement(VerticalHarness));
172
+ const scrollEl = query('.ncua-table__scroll-container');
173
+ const thumbEl = query('.ncua-table__scrollbar-thumb');
174
+ const trackEl = query('.ncua-table__scrollbar');
175
+ defineMetrics(scrollEl, {
176
+ clientHeight: LAYOUT.viewportHeight,
177
+ scrollHeight: LAYOUT.contentHeight,
178
+ scrollTop: 0
179
+ });
180
+ defineMetrics(trackEl, {
181
+ clientHeight: LAYOUT.trackHeight
182
+ });
183
+ return {
184
+ scrollEl,
185
+ thumbEl
186
+ };
187
+ };
188
+ it('오버플로 발생 시 트랙 길이 기준으로 썸 높이를 산출한다', () => {
189
+ const {
190
+ scrollEl,
191
+ thumbEl
192
+ } = setUpOverflow();
193
+ fireScroll(scrollEl);
194
+ // max(40, (200 / 800) * 150) = max(40, 37.5) = 40
195
+ expect(thumbEl.style.height).toBe('40px');
196
+ expect(thumbEl.style.transform).toBe('translateY(0px)');
197
+ });
198
+ it('스크롤 오프셋에 비례해 썸을 이동시킨다', () => {
199
+ const {
200
+ scrollEl,
201
+ thumbEl
202
+ } = setUpOverflow();
203
+ defineMetrics(scrollEl, {
204
+ scrollTop: LAYOUT.scrollTop
205
+ });
206
+ fireScroll(scrollEl);
207
+ // (300 / (800 - 200)) * (150 - 40) = 0.5 * 110 = 55
208
+ expect(thumbEl.style.transform).toBe('translateY(55px)');
209
+ });
210
+ it('트랙 clientHeight 가 0 이면 scroll-area 높이에서 헤더·여백을 차감한 값을 트랙 길이로 쓴다', () => {
211
+ render(/*#__PURE__*/createElement(VerticalHarness));
212
+ const scrollEl = query('.ncua-table__scroll-container');
213
+ const thumbEl = query('.ncua-table__scrollbar-thumb');
214
+ const areaEl = query('.ncua-table__scroll-area');
215
+ defineMetrics(scrollEl, {
216
+ clientHeight: LAYOUT.viewportHeight,
217
+ scrollHeight: LAYOUT.shortContentHeight,
218
+ scrollTop: 0
219
+ });
220
+ defineMetrics(query('.ncua-table__scrollbar'), {
221
+ clientHeight: 0
222
+ });
223
+ defineMetrics(areaEl, {
224
+ clientHeight: LAYOUT.areaHeight
225
+ });
226
+ fireScroll(scrollEl);
227
+ // trackHeight = 240 - 40(헤더) - 16(상하 여백) = 184 → max(40, (200/400) * 184) = 92
228
+ expect(thumbEl.style.height).toBe('92px');
229
+ });
230
+ it('오버플로가 없으면 썸 높이를 0 으로 둔다', () => {
231
+ render(/*#__PURE__*/createElement(VerticalHarness));
232
+ const scrollEl = query('.ncua-table__scroll-container');
233
+ const thumbEl = query('.ncua-table__scrollbar-thumb');
234
+ defineMetrics(scrollEl, {
235
+ clientHeight: LAYOUT.shortContentHeight,
236
+ scrollHeight: LAYOUT.shortContentHeight,
237
+ scrollTop: 0
238
+ });
239
+ fireScroll(scrollEl);
240
+ expect(thumbEl.style.height).toBe('0px');
241
+ });
242
+ it('썸 드래그는 (scrollHeight − clientHeight) / (clientHeight − thumbHeight) 비율로 환산한다', () => {
243
+ const {
244
+ scrollEl,
245
+ thumbEl
246
+ } = setUpOverflow();
247
+ defineMetrics(thumbEl, {
248
+ offsetHeight: 40
249
+ });
250
+ const areaEl = query('.ncua-table__scroll-area');
251
+ act(() => {
252
+ thumbEl.dispatchEvent(new MouseEvent('mousedown', {
253
+ bubbles: true,
254
+ clientY: 100
255
+ }));
256
+ });
257
+ expect(areaEl.hasAttribute('data-dragging')).toBe(true);
258
+ act(() => {
259
+ document.dispatchEvent(new MouseEvent('mousemove', {
260
+ clientY: 110
261
+ }));
262
+ });
263
+ // ratio = (800 - 200) / (200 - 40) = 3.75 → 10px 이동 = 37.5
264
+ const verticalRatio = (LAYOUT.contentHeight - LAYOUT.viewportHeight) / (LAYOUT.viewportHeight - LAYOUT.thumbHeight);
265
+ expect(scrollEl.scrollTop).toBe(LAYOUT.pointerDelta * verticalRatio);
266
+ act(() => {
267
+ document.dispatchEvent(new MouseEvent('mouseup'));
268
+ });
269
+ expect(areaEl.hasAttribute('data-dragging')).toBe(false);
270
+ });
271
+ it('mouseup 이후에는 포인터 이동이 스크롤에 반영되지 않는다', () => {
272
+ const {
273
+ scrollEl,
274
+ thumbEl
275
+ } = setUpOverflow();
276
+ defineMetrics(thumbEl, {
277
+ offsetHeight: 40
278
+ });
279
+ act(() => {
280
+ thumbEl.dispatchEvent(new MouseEvent('mousedown', {
281
+ bubbles: true,
282
+ clientY: 100
283
+ }));
284
+ document.dispatchEvent(new MouseEvent('mouseup'));
285
+ document.dispatchEvent(new MouseEvent('mousemove', {
286
+ clientY: 400
287
+ }));
288
+ });
289
+ expect(scrollEl.scrollTop).toBe(0);
290
+ });
291
+ it('enabled 가 false 이면 바인딩하지 않는다', () => {
292
+ render(/*#__PURE__*/createElement(VerticalHarness, {
293
+ enabled: false
294
+ }));
295
+ expect(observers).toHaveLength(0);
296
+ });
297
+ it('언마운트 시 ResizeObserver 를 해제한다', () => {
298
+ render(/*#__PURE__*/createElement(VerticalHarness));
299
+ act(() => {
300
+ root.unmount();
301
+ });
302
+ expect(observers.every(observer => observer.disconnected)).toBe(true);
303
+ act(() => {
304
+ root = createRoot(container);
305
+ });
306
+ });
307
+ });
308
+ describe('useTableHorizontalScrollbar', () => {
309
+ const setUpOverflow = () => {
310
+ render(/*#__PURE__*/createElement(HorizontalHarness));
311
+ const hScrollEl = query('.ncua-table__h-scroll-container');
312
+ const hThumbEl = query('.ncua-table__h-scrollbar-thumb');
313
+ const hTrackEl = query('.ncua-table__h-scrollbar');
314
+ defineMetrics(hScrollEl, {
315
+ clientWidth: LAYOUT.viewportWidth,
316
+ scrollWidth: LAYOUT.contentWidth,
317
+ scrollLeft: 0
318
+ });
319
+ return {
320
+ hScrollEl,
321
+ hThumbEl,
322
+ hTrackEl
323
+ };
324
+ };
325
+ it('트랙 wrapper 를 스크롤 오프셋만큼 보정하고 좌우 여백을 차감한 폭으로 맞춘다', () => {
326
+ const {
327
+ hScrollEl,
328
+ hTrackEl
329
+ } = setUpOverflow();
330
+ defineMetrics(hScrollEl, {
331
+ scrollLeft: LAYOUT.scrollLeft
332
+ });
333
+ fireScroll(hScrollEl);
334
+ expect(hTrackEl.style.transform).toBe('translateX(120px)');
335
+ // 600 - 8(좌) - 8(우) = 584
336
+ expect(hTrackEl.style.width).toBe('584px');
337
+ });
338
+ it('썸 폭은 트랙 유효 길이 기준으로 산출한다', () => {
339
+ const {
340
+ hScrollEl,
341
+ hThumbEl
342
+ } = setUpOverflow();
343
+ fireScroll(hScrollEl);
344
+ // trackWidth = 600 - 16 = 584 → max(40, (600 / 1500) * 584) = 233.6
345
+ expect(hThumbEl.style.width).toBe('233.60000000000002px');
346
+ expect(hThumbEl.style.transform).toBe('translateX(0px)');
347
+ });
348
+ it('오버플로가 없으면 썸 폭을 0 으로 둔다', () => {
349
+ render(/*#__PURE__*/createElement(HorizontalHarness));
350
+ const hScrollEl = query('.ncua-table__h-scroll-container');
351
+ const hThumbEl = query('.ncua-table__h-scrollbar-thumb');
352
+ defineMetrics(hScrollEl, {
353
+ clientWidth: LAYOUT.viewportWidth,
354
+ scrollWidth: LAYOUT.viewportWidth,
355
+ scrollLeft: 0
356
+ });
357
+ fireScroll(hScrollEl);
358
+ expect(hThumbEl.style.width).toBe('0px');
359
+ });
360
+ it('썸 드래그는 (scrollWidth − clientWidth) / (trackWidth − thumbWidth) 비율로 환산한다', () => {
361
+ const {
362
+ hScrollEl,
363
+ hThumbEl
364
+ } = setUpOverflow();
365
+ defineMetrics(hThumbEl, {
366
+ offsetWidth: LAYOUT.thumbWidth
367
+ });
368
+ act(() => {
369
+ hThumbEl.dispatchEvent(new MouseEvent('mousedown', {
370
+ bubbles: true,
371
+ clientX: 50
372
+ }));
373
+ });
374
+ expect(hScrollEl.hasAttribute('data-dragging')).toBe(true);
375
+ act(() => {
376
+ document.dispatchEvent(new MouseEvent('mousemove', {
377
+ clientX: 60
378
+ }));
379
+ });
380
+ // ratio = (1500 - 600) / (584 - 234) = 900 / 350 → 10px 이동 = 25.714…
381
+ expect(hScrollEl.scrollLeft).toBeCloseTo(EXPECTED_HORIZONTAL_SCROLL_LEFT, PRECISION);
382
+ act(() => {
383
+ document.dispatchEvent(new MouseEvent('mouseup'));
384
+ });
385
+ expect(hScrollEl.hasAttribute('data-dragging')).toBe(false);
386
+ });
387
+ });