@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.
- package/dist/cjs/assets/scripts/table/scrollbar/const.js +50 -0
- package/dist/cjs/assets/scripts/table/scrollbar/drag.js +75 -0
- package/dist/cjs/assets/scripts/table/scrollbar/geometry.js +69 -0
- package/dist/cjs/assets/scripts/table/scrollbar/isolate.js +36 -0
- package/dist/cjs/src/components/data-display/table/Table.js +1 -0
- package/dist/cjs/src/components/data-display/table/__tests__/useTableScrollbars.characterisation.test.js +390 -0
- package/dist/cjs/src/components/data-display/table/__tests__/useTableScrollbars.regression.test.js +339 -0
- package/dist/cjs/src/components/data-display/table/useTableScrollbars.js +115 -86
- package/dist/cjs/src/components/feedback-and-status/badge/Badge.js +1 -1
- package/dist/cjs/src/components/overlays/modal/Modal.js +46 -41
- package/dist/cjs/src/components/overlays/postcode-search-modal/PostcodeSearchModal.js +2 -2
- package/dist/cjs/src/generated/scoped-css.js +1 -1
- package/dist/esm/assets/scripts/table/scrollbar/const.js +44 -0
- package/dist/esm/assets/scripts/table/scrollbar/drag.js +67 -0
- package/dist/esm/assets/scripts/table/scrollbar/geometry.js +60 -0
- package/dist/esm/assets/scripts/table/scrollbar/isolate.js +27 -0
- package/dist/esm/src/components/data-display/table/Table.js +1 -0
- package/dist/esm/src/components/data-display/table/__tests__/useTableScrollbars.characterisation.test.js +387 -0
- package/dist/esm/src/components/data-display/table/__tests__/useTableScrollbars.regression.test.js +336 -0
- package/dist/esm/src/components/data-display/table/useTableScrollbars.js +85 -86
- package/dist/esm/src/components/feedback-and-status/badge/Badge.js +1 -1
- package/dist/esm/src/components/overlays/modal/Modal.js +47 -42
- package/dist/esm/src/components/overlays/postcode-search-modal/PostcodeSearchModal.js +2 -2
- package/dist/esm/src/generated/scoped-css.js +1 -1
- package/dist/temp/assets/scripts/table/scrollbar/const.d.ts +46 -0
- package/dist/temp/assets/scripts/table/scrollbar/const.js +44 -0
- package/dist/temp/assets/scripts/table/scrollbar/drag.d.ts +18 -0
- package/dist/temp/assets/scripts/table/scrollbar/drag.js +49 -0
- package/dist/temp/assets/scripts/table/scrollbar/geometry.d.ts +47 -0
- package/dist/temp/assets/scripts/table/scrollbar/geometry.js +37 -0
- package/dist/temp/assets/scripts/table/scrollbar/isolate.d.ts +4 -0
- package/dist/temp/assets/scripts/table/scrollbar/isolate.js +30 -0
- package/dist/temp/src/components/data-display/table/Table.js +1 -0
- package/dist/temp/src/components/data-display/table/__tests__/useTableScrollbars.characterisation.test.d.ts +1 -0
- package/dist/temp/src/components/data-display/table/__tests__/useTableScrollbars.characterisation.test.js +282 -0
- package/dist/temp/src/components/data-display/table/__tests__/useTableScrollbars.regression.test.d.ts +1 -0
- package/dist/temp/src/components/data-display/table/__tests__/useTableScrollbars.regression.test.js +244 -0
- package/dist/temp/src/components/data-display/table/useTableScrollbars.d.ts +2 -6
- package/dist/temp/src/components/data-display/table/useTableScrollbars.js +100 -72
- package/dist/temp/src/components/feedback-and-status/badge/Badge.d.ts +1 -1
- package/dist/temp/src/components/feedback-and-status/badge/Badge.js +1 -1
- package/dist/temp/src/components/overlays/modal/Modal.d.ts +5 -5
- package/dist/temp/src/components/overlays/modal/Modal.js +11 -14
- package/dist/temp/src/components/overlays/postcode-search-modal/PostcodeSearchModal.js +1 -1
- package/dist/temp/src/generated/scoped-css.js +1 -1
- package/dist/temp/src/hooks/useIsomorphicLayoutEffect.d.ts +2 -2
- package/dist/types/assets/scripts/table/scrollbar/const.d.ts +46 -0
- package/dist/types/assets/scripts/table/scrollbar/drag.d.ts +18 -0
- package/dist/types/assets/scripts/table/scrollbar/geometry.d.ts +47 -0
- package/dist/types/assets/scripts/table/scrollbar/isolate.d.ts +4 -0
- package/dist/types/src/components/data-display/table/__tests__/useTableScrollbars.characterisation.test.d.ts +1 -0
- package/dist/types/src/components/data-display/table/__tests__/useTableScrollbars.regression.test.d.ts +1 -0
- package/dist/types/src/components/data-display/table/useTableScrollbars.d.ts +2 -6
- package/dist/types/src/components/feedback-and-status/badge/Badge.d.ts +1 -1
- package/dist/types/src/components/overlays/modal/Modal.d.ts +5 -5
- package/dist/types/src/hooks/useIsomorphicLayoutEffect.d.ts +2 -2
- package/dist/ui-admin/assets/styles/style.css +57 -28
- package/package.json +1 -1
package/dist/esm/src/components/data-display/table/__tests__/useTableScrollbars.regression.test.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* 훅에 새로 추가한 동작만 다룬다.
|
|
4
|
+
* 기존 동작 무변경은 `useTableScrollbars.characterisation.test.ts` 가 고정한다.
|
|
5
|
+
*/
|
|
6
|
+
import { createElement, useEffect, 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 { SCROLLBAR_ATTRS, SCROLLBAR_OWNERS } from '../../../../../assets/scripts/table/scrollbar/const';
|
|
11
|
+
import { useTableHorizontalScrollbar, useTableVerticalScrollbar } from '../useTableScrollbars';
|
|
12
|
+
const observers = [];
|
|
13
|
+
class ResizeObserverStub {
|
|
14
|
+
constructor(callback) {
|
|
15
|
+
this.record = {
|
|
16
|
+
callback,
|
|
17
|
+
targets: [],
|
|
18
|
+
disconnected: false
|
|
19
|
+
};
|
|
20
|
+
observers.push(this.record);
|
|
21
|
+
}
|
|
22
|
+
observe(target) {
|
|
23
|
+
this.record.targets.push(target);
|
|
24
|
+
}
|
|
25
|
+
unobserve() {
|
|
26
|
+
// 전체 해제만 검증 대상이다.
|
|
27
|
+
}
|
|
28
|
+
disconnect() {
|
|
29
|
+
this.record.disconnected = true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
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 probe = {
|
|
44
|
+
markerAtPassiveEffect: false
|
|
45
|
+
};
|
|
46
|
+
const render = element => {
|
|
47
|
+
act(() => {
|
|
48
|
+
root.render(element);
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
const VerticalHarness = _ref => {
|
|
52
|
+
let {
|
|
53
|
+
enabled = true
|
|
54
|
+
} = _ref;
|
|
55
|
+
const scrollContainerRef = useRef(null);
|
|
56
|
+
const scrollAreaRef = useRef(null);
|
|
57
|
+
const scrollbarRef = useRef(null);
|
|
58
|
+
const thumbRef = useRef(null);
|
|
59
|
+
// 훅보다 먼저 선언한 passive effect. 훅이 layout effect 를 쓰면 이 시점에 이미 부착돼 있다.
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
probe.markerAtPassiveEffect = scrollContainerRef.current?.hasAttribute(SCROLLBAR_ATTRS.ready) ?? false;
|
|
62
|
+
});
|
|
63
|
+
const {
|
|
64
|
+
handleThumbMouseDown
|
|
65
|
+
} = useTableVerticalScrollbar({
|
|
66
|
+
enabled,
|
|
67
|
+
layoutKey: false,
|
|
68
|
+
scrollContainerRef,
|
|
69
|
+
scrollAreaRef,
|
|
70
|
+
scrollbarRef,
|
|
71
|
+
thumbRef
|
|
72
|
+
});
|
|
73
|
+
return /*#__PURE__*/createElement('div', {
|
|
74
|
+
className: 'ncua-table ncua-table--fixed-header'
|
|
75
|
+
}, /*#__PURE__*/createElement('div', {
|
|
76
|
+
ref: scrollAreaRef,
|
|
77
|
+
className: 'ncua-table__scroll-area'
|
|
78
|
+
}, /*#__PURE__*/createElement('div', {
|
|
79
|
+
ref: scrollContainerRef,
|
|
80
|
+
className: 'ncua-table__scroll-container'
|
|
81
|
+
}, /*#__PURE__*/createElement('table', {
|
|
82
|
+
className: 'ncua-table__table'
|
|
83
|
+
}, /*#__PURE__*/createElement('tbody', null))), /*#__PURE__*/createElement('div', {
|
|
84
|
+
ref: scrollbarRef,
|
|
85
|
+
className: 'ncua-table__scrollbar'
|
|
86
|
+
}, /*#__PURE__*/createElement('div', {
|
|
87
|
+
ref: thumbRef,
|
|
88
|
+
className: 'ncua-table__scrollbar-thumb',
|
|
89
|
+
onMouseDown: handleThumbMouseDown
|
|
90
|
+
}))));
|
|
91
|
+
};
|
|
92
|
+
const HorizontalHarness = () => {
|
|
93
|
+
const hScrollContainerRef = useRef(null);
|
|
94
|
+
const hScrollbarRef = useRef(null);
|
|
95
|
+
const hThumbRef = useRef(null);
|
|
96
|
+
const {
|
|
97
|
+
handleHThumbMouseDown
|
|
98
|
+
} = useTableHorizontalScrollbar({
|
|
99
|
+
enabled: true,
|
|
100
|
+
hScrollContainerRef,
|
|
101
|
+
hScrollbarRef,
|
|
102
|
+
hThumbRef
|
|
103
|
+
});
|
|
104
|
+
return /*#__PURE__*/createElement('div', {
|
|
105
|
+
className: 'ncua-table'
|
|
106
|
+
}, /*#__PURE__*/createElement('div', {
|
|
107
|
+
ref: hScrollContainerRef,
|
|
108
|
+
className: 'ncua-table__h-scroll-container'
|
|
109
|
+
}, /*#__PURE__*/createElement('div', {
|
|
110
|
+
className: 'ncua-table__h-scroll-inner'
|
|
111
|
+
}, /*#__PURE__*/createElement('table', {
|
|
112
|
+
className: 'ncua-table__table'
|
|
113
|
+
}, /*#__PURE__*/createElement('tbody', null))), /*#__PURE__*/createElement('div', {
|
|
114
|
+
ref: hScrollbarRef,
|
|
115
|
+
className: 'ncua-table__h-scrollbar'
|
|
116
|
+
}, /*#__PURE__*/createElement('div', {
|
|
117
|
+
ref: hThumbRef,
|
|
118
|
+
className: 'ncua-table__h-scrollbar-thumb',
|
|
119
|
+
onMouseDown: handleHThumbMouseDown
|
|
120
|
+
}))));
|
|
121
|
+
};
|
|
122
|
+
/** 누적 여부를 드러내기 위한 반복 드래그 횟수 */
|
|
123
|
+
const DRAG_REPEAT = 5;
|
|
124
|
+
const query = selector => container.querySelector(selector);
|
|
125
|
+
beforeEach(() => {
|
|
126
|
+
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
|
|
127
|
+
observers.length = 0;
|
|
128
|
+
probe.markerAtPassiveEffect = false;
|
|
129
|
+
container = document.createElement('div');
|
|
130
|
+
document.body.appendChild(container);
|
|
131
|
+
act(() => {
|
|
132
|
+
root = createRoot(container);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
act(() => {
|
|
137
|
+
root.unmount();
|
|
138
|
+
});
|
|
139
|
+
container.remove();
|
|
140
|
+
vi.unstubAllGlobals();
|
|
141
|
+
vi.restoreAllMocks();
|
|
142
|
+
});
|
|
143
|
+
describe('표식 부착', () => {
|
|
144
|
+
it('마운트 시 스크롤 컨테이너에 data-scrollbar-ready 를 부착한다', () => {
|
|
145
|
+
render(/*#__PURE__*/createElement(VerticalHarness));
|
|
146
|
+
expect(query('.ncua-table__scroll-container').hasAttribute(SCROLLBAR_ATTRS.ready)).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
it('표식을 layout effect 에서 부착해 페인트 전에 반영한다', () => {
|
|
149
|
+
render(/*#__PURE__*/createElement(VerticalHarness));
|
|
150
|
+
expect(probe.markerAtPassiveEffect).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
it('소유자 표식(react)을 함께 부착한다', () => {
|
|
153
|
+
render(/*#__PURE__*/createElement(VerticalHarness));
|
|
154
|
+
expect(query('.ncua-table__scroll-container').getAttribute(SCROLLBAR_ATTRS.owner)).toBe(SCROLLBAR_OWNERS.react);
|
|
155
|
+
});
|
|
156
|
+
it('언마운트 시 표식과 소유자 표식을 모두 제거한다', () => {
|
|
157
|
+
render(/*#__PURE__*/createElement(VerticalHarness));
|
|
158
|
+
const scrollEl = query('.ncua-table__scroll-container');
|
|
159
|
+
act(() => {
|
|
160
|
+
root.unmount();
|
|
161
|
+
});
|
|
162
|
+
expect(scrollEl.hasAttribute(SCROLLBAR_ATTRS.ready)).toBe(false);
|
|
163
|
+
expect(scrollEl.hasAttribute(SCROLLBAR_ATTRS.owner)).toBe(false);
|
|
164
|
+
act(() => {
|
|
165
|
+
root = createRoot(container);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
it('가로축도 자기 스크롤 컨테이너에 표식을 부착한다', () => {
|
|
169
|
+
render(/*#__PURE__*/createElement(HorizontalHarness));
|
|
170
|
+
expect(query('.ncua-table__h-scroll-container').hasAttribute(SCROLLBAR_ATTRS.ready)).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
it('타 소유자가 선점한 컨테이너는 덮어쓰지 않고 바인딩도 하지 않는다', () => {
|
|
173
|
+
render(/*#__PURE__*/createElement(VerticalHarness, {
|
|
174
|
+
enabled: false
|
|
175
|
+
}));
|
|
176
|
+
const scrollEl = query('.ncua-table__scroll-container');
|
|
177
|
+
scrollEl.setAttribute(SCROLLBAR_ATTRS.ready, '');
|
|
178
|
+
scrollEl.setAttribute(SCROLLBAR_ATTRS.owner, SCROLLBAR_OWNERS.cdn);
|
|
179
|
+
observers.length = 0;
|
|
180
|
+
render(/*#__PURE__*/createElement(VerticalHarness, {
|
|
181
|
+
enabled: true
|
|
182
|
+
}));
|
|
183
|
+
expect(scrollEl.getAttribute(SCROLLBAR_ATTRS.owner)).toBe(SCROLLBAR_OWNERS.cdn);
|
|
184
|
+
expect(observers).toHaveLength(0);
|
|
185
|
+
});
|
|
186
|
+
it('타 소유자가 선점한 표식은 언마운트에서도 제거하지 않는다', () => {
|
|
187
|
+
render(/*#__PURE__*/createElement(VerticalHarness, {
|
|
188
|
+
enabled: false
|
|
189
|
+
}));
|
|
190
|
+
const scrollEl = query('.ncua-table__scroll-container');
|
|
191
|
+
scrollEl.setAttribute(SCROLLBAR_ATTRS.ready, '');
|
|
192
|
+
scrollEl.setAttribute(SCROLLBAR_ATTRS.owner, SCROLLBAR_OWNERS.cdn);
|
|
193
|
+
render(/*#__PURE__*/createElement(VerticalHarness, {
|
|
194
|
+
enabled: true
|
|
195
|
+
}));
|
|
196
|
+
act(() => {
|
|
197
|
+
root.unmount();
|
|
198
|
+
});
|
|
199
|
+
expect(scrollEl.hasAttribute(SCROLLBAR_ATTRS.ready)).toBe(true);
|
|
200
|
+
expect(scrollEl.getAttribute(SCROLLBAR_ATTRS.owner)).toBe(SCROLLBAR_OWNERS.cdn);
|
|
201
|
+
act(() => {
|
|
202
|
+
root = createRoot(container);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
describe('ResizeObserver 관찰 대상', () => {
|
|
207
|
+
it('세로축이 스크롤 콘텐츠 루트(<table>)를 함께 관찰한다', () => {
|
|
208
|
+
render(/*#__PURE__*/createElement(VerticalHarness));
|
|
209
|
+
expect(observers[0]?.targets).toContain(query('.ncua-table__table'));
|
|
210
|
+
});
|
|
211
|
+
it('컨테이너 치수 불변 + 콘텐츠 증가 통지에 썸 높이를 재산출한다', () => {
|
|
212
|
+
render(/*#__PURE__*/createElement(VerticalHarness));
|
|
213
|
+
const scrollEl = query('.ncua-table__scroll-container');
|
|
214
|
+
const thumbEl = query('.ncua-table__scrollbar-thumb');
|
|
215
|
+
defineMetrics(scrollEl, {
|
|
216
|
+
clientHeight: 200,
|
|
217
|
+
scrollHeight: 400,
|
|
218
|
+
scrollTop: 0
|
|
219
|
+
});
|
|
220
|
+
defineMetrics(query('.ncua-table__scrollbar'), {
|
|
221
|
+
clientHeight: 160
|
|
222
|
+
});
|
|
223
|
+
act(() => {
|
|
224
|
+
observers[0]?.callback();
|
|
225
|
+
});
|
|
226
|
+
expect(thumbEl.style.height).toBe('80px');
|
|
227
|
+
// 컨테이너 박스는 그대로인 채 콘텐츠만 늘어난 경우
|
|
228
|
+
defineMetrics(scrollEl, {
|
|
229
|
+
scrollHeight: 800
|
|
230
|
+
});
|
|
231
|
+
act(() => {
|
|
232
|
+
observers[0]?.callback();
|
|
233
|
+
});
|
|
234
|
+
expect(thumbEl.style.height).toBe('40px');
|
|
235
|
+
});
|
|
236
|
+
it('가로축이 __h-scroll-inner 와 콘텐츠 루트를 함께 관찰한다', () => {
|
|
237
|
+
render(/*#__PURE__*/createElement(HorizontalHarness));
|
|
238
|
+
expect(observers[0]?.targets).toContain(query('.ncua-table__h-scroll-inner'));
|
|
239
|
+
expect(observers[0]?.targets).toContain(query('.ncua-table__table'));
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
describe('드래그 해제 경로', () => {
|
|
243
|
+
it('pointercancel 로도 드래그가 종료된다', () => {
|
|
244
|
+
render(/*#__PURE__*/createElement(VerticalHarness));
|
|
245
|
+
const scrollEl = query('.ncua-table__scroll-container');
|
|
246
|
+
const thumbEl = query('.ncua-table__scrollbar-thumb');
|
|
247
|
+
const areaEl = query('.ncua-table__scroll-area');
|
|
248
|
+
defineMetrics(scrollEl, {
|
|
249
|
+
clientHeight: 200,
|
|
250
|
+
scrollHeight: 800,
|
|
251
|
+
scrollTop: 0
|
|
252
|
+
});
|
|
253
|
+
defineMetrics(thumbEl, {
|
|
254
|
+
offsetHeight: 40
|
|
255
|
+
});
|
|
256
|
+
act(() => {
|
|
257
|
+
thumbEl.dispatchEvent(new MouseEvent('mousedown', {
|
|
258
|
+
bubbles: true,
|
|
259
|
+
clientY: 100
|
|
260
|
+
}));
|
|
261
|
+
});
|
|
262
|
+
act(() => {
|
|
263
|
+
document.dispatchEvent(new Event('pointercancel'));
|
|
264
|
+
document.dispatchEvent(new MouseEvent('mousemove', {
|
|
265
|
+
clientY: 400
|
|
266
|
+
}));
|
|
267
|
+
});
|
|
268
|
+
expect(scrollEl.scrollTop).toBe(0);
|
|
269
|
+
expect(areaEl.hasAttribute(SCROLLBAR_ATTRS.dragging)).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
it('드래그를 반복해도 해제 신호에 리스너가 누적되지 않는다', () => {
|
|
272
|
+
render(/*#__PURE__*/createElement(VerticalHarness));
|
|
273
|
+
const scrollEl = query('.ncua-table__scroll-container');
|
|
274
|
+
const thumbEl = query('.ncua-table__scrollbar-thumb');
|
|
275
|
+
const areaEl = query('.ncua-table__scroll-area');
|
|
276
|
+
defineMetrics(scrollEl, {
|
|
277
|
+
clientHeight: 200,
|
|
278
|
+
scrollHeight: 800,
|
|
279
|
+
scrollTop: 0
|
|
280
|
+
});
|
|
281
|
+
defineMetrics(thumbEl, {
|
|
282
|
+
offsetHeight: 40
|
|
283
|
+
});
|
|
284
|
+
// 누적이 있으면 잔존 클로저가 언마운트 시 드래그 횟수만큼 추가 발화한다.
|
|
285
|
+
for (let i = 0; i < DRAG_REPEAT; i += 1) {
|
|
286
|
+
act(() => {
|
|
287
|
+
thumbEl.dispatchEvent(new MouseEvent('mousedown', {
|
|
288
|
+
bubbles: true,
|
|
289
|
+
clientY: 100
|
|
290
|
+
}));
|
|
291
|
+
document.dispatchEvent(new MouseEvent('mouseup'));
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
const removeSpy = vi.spyOn(areaEl, 'removeAttribute');
|
|
295
|
+
act(() => {
|
|
296
|
+
root.unmount();
|
|
297
|
+
});
|
|
298
|
+
const draggingRemovals = removeSpy.mock.calls.filter(_ref2 => {
|
|
299
|
+
let [name] = _ref2;
|
|
300
|
+
return name === SCROLLBAR_ATTRS.dragging;
|
|
301
|
+
});
|
|
302
|
+
expect(draggingRemovals).toHaveLength(0);
|
|
303
|
+
act(() => {
|
|
304
|
+
root = createRoot(container);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
it('언마운트가 드래그보다 먼저 일어나도 document 리스너가 남지 않는다', () => {
|
|
308
|
+
render(/*#__PURE__*/createElement(VerticalHarness));
|
|
309
|
+
const scrollEl = query('.ncua-table__scroll-container');
|
|
310
|
+
const thumbEl = query('.ncua-table__scrollbar-thumb');
|
|
311
|
+
defineMetrics(scrollEl, {
|
|
312
|
+
clientHeight: 200,
|
|
313
|
+
scrollHeight: 800,
|
|
314
|
+
scrollTop: 0
|
|
315
|
+
});
|
|
316
|
+
defineMetrics(thumbEl, {
|
|
317
|
+
offsetHeight: 40
|
|
318
|
+
});
|
|
319
|
+
act(() => {
|
|
320
|
+
thumbEl.dispatchEvent(new MouseEvent('mousedown', {
|
|
321
|
+
bubbles: true,
|
|
322
|
+
clientY: 100
|
|
323
|
+
}));
|
|
324
|
+
});
|
|
325
|
+
act(() => {
|
|
326
|
+
root.unmount();
|
|
327
|
+
});
|
|
328
|
+
document.dispatchEvent(new MouseEvent('mousemove', {
|
|
329
|
+
clientY: 400
|
|
330
|
+
}));
|
|
331
|
+
expect(scrollEl.scrollTop).toBe(0);
|
|
332
|
+
act(() => {
|
|
333
|
+
root = createRoot(container);
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
});
|
|
@@ -1,67 +1,23 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
draggingTarget = scrollEl,
|
|
22
|
-
sideGap = 0
|
|
23
|
-
} = options;
|
|
24
|
-
draggingTarget.setAttribute('data-dragging', '');
|
|
25
|
-
if (axis === 'y') {
|
|
26
|
-
const startY = e.clientY;
|
|
27
|
-
const startScrollTop = scrollEl.scrollTop;
|
|
28
|
-
const {
|
|
29
|
-
scrollHeight,
|
|
30
|
-
clientHeight
|
|
31
|
-
} = scrollEl;
|
|
32
|
-
const thumbHeight = thumbEl.offsetHeight;
|
|
33
|
-
const ratio = (scrollHeight - clientHeight) / (clientHeight - thumbHeight);
|
|
34
|
-
const onMove = ev => {
|
|
35
|
-
scrollEl.scrollTop = startScrollTop + (ev.clientY - startY) * ratio;
|
|
36
|
-
};
|
|
37
|
-
const onUp = () => {
|
|
38
|
-
draggingTarget.removeAttribute('data-dragging');
|
|
39
|
-
document.removeEventListener('mousemove', onMove);
|
|
40
|
-
document.removeEventListener('mouseup', onUp);
|
|
41
|
-
};
|
|
42
|
-
document.addEventListener('mousemove', onMove);
|
|
43
|
-
document.addEventListener('mouseup', onUp);
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
const startX = e.clientX;
|
|
47
|
-
const startScrollLeft = scrollEl.scrollLeft;
|
|
48
|
-
const {
|
|
49
|
-
scrollWidth,
|
|
50
|
-
clientWidth
|
|
51
|
-
} = scrollEl;
|
|
52
|
-
const thumbWidth = thumbEl.offsetWidth;
|
|
53
|
-
const trackWidth = clientWidth - sideGap * 2;
|
|
54
|
-
const ratio = (scrollWidth - clientWidth) / (trackWidth - thumbWidth);
|
|
55
|
-
const onMove = ev => {
|
|
56
|
-
scrollEl.scrollLeft = startScrollLeft + (ev.clientX - startX) * ratio;
|
|
57
|
-
};
|
|
58
|
-
const onUp = () => {
|
|
59
|
-
draggingTarget.removeAttribute('data-dragging');
|
|
60
|
-
document.removeEventListener('mousemove', onMove);
|
|
61
|
-
document.removeEventListener('mouseup', onUp);
|
|
62
|
-
};
|
|
63
|
-
document.addEventListener('mousemove', onMove);
|
|
64
|
-
document.addEventListener('mouseup', onUp);
|
|
1
|
+
import { useRef } from 'react';
|
|
2
|
+
import { H_SCROLLBAR_SIDE_GAP, H_SCROLLBAR_THUMB_MIN_WIDTH, SCROLLBAR_ATTRS, SCROLLBAR_OWNERS, SCROLLBAR_THUMB_MIN_HEIGHT, SCROLLBAR_TRACK_OFFSET, TABLE_HEADER_HEIGHT } from '../../../../assets/scripts/table/scrollbar/const';
|
|
3
|
+
import { startThumbDrag } from '../../../../assets/scripts/table/scrollbar/drag';
|
|
4
|
+
import { computeHorizontalTrackBox, computeThumbGeometry } from '../../../../assets/scripts/table/scrollbar/geometry';
|
|
5
|
+
import { useIsomorphicLayoutEffect } from '../../../hooks/useIsomorphicLayoutEffect';
|
|
6
|
+
// CDN 바인딩과 같은 값을 쓰기 위해 재노출한다. 정의는 scrollbar/const.ts 에 있다.
|
|
7
|
+
export { H_SCROLLBAR_SIDE_GAP, H_SCROLLBAR_THUMB_MIN_WIDTH, SCROLLBAR_THUMB_MIN_HEIGHT, SCROLLBAR_TRACK_OFFSET, TABLE_HEADER_HEIGHT };
|
|
8
|
+
/** 타 소유자가 선점한 컨테이너는 덮어쓰지 않는다. CDN 생성자의 소유권 검사와 대칭이다 */
|
|
9
|
+
const claimMarkers = el => {
|
|
10
|
+
const owner = el.getAttribute(SCROLLBAR_ATTRS.owner);
|
|
11
|
+
if (el.hasAttribute(SCROLLBAR_ATTRS.ready) && owner !== null && owner !== SCROLLBAR_OWNERS.react) return false;
|
|
12
|
+
el.setAttribute(SCROLLBAR_ATTRS.ready, '');
|
|
13
|
+
el.setAttribute(SCROLLBAR_ATTRS.owner, SCROLLBAR_OWNERS.react);
|
|
14
|
+
return true;
|
|
15
|
+
};
|
|
16
|
+
/** 자기가 붙인 표식만 뗀다. CDN 인스턴스가 살아 있는 표식을 훅이 떼면 양쪽이 모두 어긋난다 */
|
|
17
|
+
const releaseMarkers = el => {
|
|
18
|
+
if (el.getAttribute(SCROLLBAR_ATTRS.owner) !== SCROLLBAR_OWNERS.react) return;
|
|
19
|
+
el.removeAttribute(SCROLLBAR_ATTRS.ready);
|
|
20
|
+
el.removeAttribute(SCROLLBAR_ATTRS.owner);
|
|
65
21
|
};
|
|
66
22
|
export const useTableVerticalScrollbar = _ref => {
|
|
67
23
|
let {
|
|
@@ -72,27 +28,40 @@ export const useTableVerticalScrollbar = _ref => {
|
|
|
72
28
|
scrollbarRef,
|
|
73
29
|
thumbRef
|
|
74
30
|
} = _ref;
|
|
75
|
-
|
|
76
|
-
|
|
31
|
+
const releaseRef = useRef(null);
|
|
32
|
+
// 표식을 페인트 전에 붙여야 스크롤바가 한 프레임 늦게 나타나지 않는다.
|
|
33
|
+
// layoutKey 는 직접 참조하지 않지만, horizontalScroll 토글로 스크롤바 DOM 이 리마운트될 때
|
|
34
|
+
// effect 를 재바인딩하기 위한 트리거로 의존성 배열에 넣는다.
|
|
35
|
+
useIsomorphicLayoutEffect(() => {
|
|
77
36
|
if (!enabled) return;
|
|
78
37
|
const scrollEl = scrollContainerRef.current;
|
|
79
38
|
const thumbEl = thumbRef.current;
|
|
80
39
|
if (!scrollEl || !thumbEl) return;
|
|
40
|
+
// 소유자 표식이 있어야 CDN 자동 초기화가 이 테이블을 건드리지 않는다.
|
|
41
|
+
// 선점 실패 시 리스너·옵저버 등록 전에 빠져야 이중 구동이 생기지 않는다.
|
|
42
|
+
if (!claimMarkers(scrollEl)) return;
|
|
43
|
+
const controller = new AbortController();
|
|
44
|
+
releaseRef.current = controller;
|
|
81
45
|
const update = () => {
|
|
82
46
|
const {
|
|
83
47
|
scrollTop,
|
|
84
48
|
scrollHeight,
|
|
85
49
|
clientHeight
|
|
86
50
|
} = scrollEl;
|
|
87
|
-
|
|
51
|
+
const trackSize = scrollbarRef.current?.clientHeight || (scrollAreaRef.current?.clientHeight ?? clientHeight) - TABLE_HEADER_HEIGHT - SCROLLBAR_TRACK_OFFSET;
|
|
52
|
+
const geometry = computeThumbGeometry({
|
|
53
|
+
scrollOffset: scrollTop,
|
|
54
|
+
scrollSize: scrollHeight,
|
|
55
|
+
clientSize: clientHeight,
|
|
56
|
+
trackSize,
|
|
57
|
+
thumbMinSize: SCROLLBAR_THUMB_MIN_HEIGHT
|
|
58
|
+
});
|
|
59
|
+
if (!geometry.visible) {
|
|
88
60
|
thumbEl.style.height = '0';
|
|
89
61
|
return;
|
|
90
62
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const thumbTop = scrollTop / (scrollHeight - clientHeight) * (trackHeight - thumbHeight);
|
|
94
|
-
thumbEl.style.height = `${thumbHeight}px`;
|
|
95
|
-
thumbEl.style.transform = `translateY(${thumbTop}px)`;
|
|
63
|
+
thumbEl.style.height = `${geometry.size}px`;
|
|
64
|
+
thumbEl.style.transform = `translateY(${geometry.offset}px)`;
|
|
96
65
|
};
|
|
97
66
|
scrollEl.addEventListener('scroll', update, {
|
|
98
67
|
passive: true
|
|
@@ -101,10 +70,16 @@ export const useTableVerticalScrollbar = _ref => {
|
|
|
101
70
|
observer.observe(scrollEl);
|
|
102
71
|
if (scrollAreaRef.current) observer.observe(scrollAreaRef.current);
|
|
103
72
|
if (scrollbarRef.current) observer.observe(scrollbarRef.current);
|
|
73
|
+
// 컨테이너는 maxHeight 로 고정되므로 콘텐츠 루트까지 관찰해야 행 추가가 통지된다.
|
|
74
|
+
const contentEl = scrollEl.querySelector('.ncua-table__table');
|
|
75
|
+
if (contentEl) observer.observe(contentEl);
|
|
104
76
|
update();
|
|
105
77
|
return () => {
|
|
106
78
|
scrollEl.removeEventListener('scroll', update);
|
|
107
79
|
observer.disconnect();
|
|
80
|
+
controller.abort();
|
|
81
|
+
releaseRef.current = null;
|
|
82
|
+
releaseMarkers(scrollEl);
|
|
108
83
|
};
|
|
109
84
|
}, [enabled, layoutKey, scrollContainerRef, scrollAreaRef, scrollbarRef, thumbRef]);
|
|
110
85
|
const handleThumbMouseDown = e => {
|
|
@@ -112,11 +87,13 @@ export const useTableVerticalScrollbar = _ref => {
|
|
|
112
87
|
const thumbEl = thumbRef.current;
|
|
113
88
|
const areaEl = scrollAreaRef.current;
|
|
114
89
|
if (!scrollEl || !thumbEl) return;
|
|
115
|
-
|
|
90
|
+
startThumbDrag(e, {
|
|
116
91
|
axis: 'y',
|
|
117
92
|
scrollEl,
|
|
118
93
|
thumbEl,
|
|
119
|
-
draggingTarget: areaEl ?? scrollEl
|
|
94
|
+
draggingTarget: areaEl ?? scrollEl,
|
|
95
|
+
measureDragBasisSize: clientSize => clientSize,
|
|
96
|
+
releaseSignal: releaseRef.current?.signal
|
|
120
97
|
});
|
|
121
98
|
};
|
|
122
99
|
return {
|
|
@@ -130,51 +107,73 @@ export const useTableHorizontalScrollbar = _ref2 => {
|
|
|
130
107
|
hScrollbarRef,
|
|
131
108
|
hThumbRef
|
|
132
109
|
} = _ref2;
|
|
133
|
-
|
|
110
|
+
const releaseRef = useRef(null);
|
|
111
|
+
useIsomorphicLayoutEffect(() => {
|
|
134
112
|
if (!enabled) return;
|
|
135
113
|
const hScrollEl = hScrollContainerRef.current;
|
|
136
114
|
const hScrollbarEl = hScrollbarRef.current;
|
|
137
115
|
const hThumbEl = hThumbRef.current;
|
|
138
116
|
if (!hScrollEl || !hScrollbarEl || !hThumbEl) return;
|
|
117
|
+
if (!claimMarkers(hScrollEl)) return;
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
releaseRef.current = controller;
|
|
139
120
|
const update = () => {
|
|
140
121
|
const {
|
|
141
122
|
scrollLeft,
|
|
142
123
|
scrollWidth,
|
|
143
124
|
clientWidth
|
|
144
125
|
} = hScrollEl;
|
|
145
|
-
|
|
126
|
+
const box = computeHorizontalTrackBox({
|
|
127
|
+
clientWidth,
|
|
128
|
+
scrollLeft,
|
|
129
|
+
sideGap: H_SCROLLBAR_SIDE_GAP
|
|
130
|
+
});
|
|
131
|
+
const geometry = computeThumbGeometry({
|
|
132
|
+
scrollOffset: scrollLeft,
|
|
133
|
+
scrollSize: scrollWidth,
|
|
134
|
+
clientSize: clientWidth,
|
|
135
|
+
trackSize: box.width,
|
|
136
|
+
thumbMinSize: H_SCROLLBAR_THUMB_MIN_WIDTH
|
|
137
|
+
});
|
|
138
|
+
if (!geometry.visible) {
|
|
146
139
|
hThumbEl.style.width = '0';
|
|
147
140
|
return;
|
|
148
141
|
}
|
|
149
|
-
|
|
150
|
-
hScrollbarEl.style.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const thumbWidth = Math.max(H_SCROLLBAR_THUMB_MIN_WIDTH, clientWidth / scrollWidth * trackWidth);
|
|
154
|
-
const thumbLeft = scrollLeft / (scrollWidth - clientWidth) * (trackWidth - thumbWidth);
|
|
155
|
-
hThumbEl.style.width = `${thumbWidth}px`;
|
|
156
|
-
hThumbEl.style.transform = `translateX(${thumbLeft}px)`;
|
|
142
|
+
hScrollbarEl.style.transform = `translateX(${box.translateX}px)`;
|
|
143
|
+
hScrollbarEl.style.width = `${box.width}px`;
|
|
144
|
+
hThumbEl.style.width = `${geometry.size}px`;
|
|
145
|
+
hThumbEl.style.transform = `translateX(${geometry.offset}px)`;
|
|
157
146
|
};
|
|
158
147
|
hScrollEl.addEventListener('scroll', update, {
|
|
159
148
|
passive: true
|
|
160
149
|
});
|
|
161
150
|
const ro = new ResizeObserver(update);
|
|
162
151
|
ro.observe(hScrollEl);
|
|
152
|
+
// 컨테이너 폭이 그대로인 채 콘텐츠 폭만 변하는 경우를 포착한다.
|
|
153
|
+
const innerEl = hScrollEl.querySelector('.ncua-table__h-scroll-inner');
|
|
154
|
+
if (innerEl) ro.observe(innerEl);
|
|
155
|
+
const contentEl = hScrollEl.querySelector('.ncua-table__table');
|
|
156
|
+
if (contentEl) ro.observe(contentEl);
|
|
163
157
|
update();
|
|
164
158
|
return () => {
|
|
165
159
|
hScrollEl.removeEventListener('scroll', update);
|
|
166
160
|
ro.disconnect();
|
|
161
|
+
controller.abort();
|
|
162
|
+
releaseRef.current = null;
|
|
163
|
+
releaseMarkers(hScrollEl);
|
|
167
164
|
};
|
|
168
165
|
}, [enabled, hScrollContainerRef, hScrollbarRef, hThumbRef]);
|
|
169
166
|
const handleHThumbMouseDown = e => {
|
|
170
167
|
const hScrollEl = hScrollContainerRef.current;
|
|
171
168
|
const hThumbEl = hThumbRef.current;
|
|
172
169
|
if (!hScrollEl || !hThumbEl) return;
|
|
173
|
-
|
|
170
|
+
startThumbDrag(e, {
|
|
174
171
|
axis: 'x',
|
|
175
172
|
scrollEl: hScrollEl,
|
|
176
173
|
thumbEl: hThumbEl,
|
|
177
|
-
|
|
174
|
+
draggingTarget: hScrollEl,
|
|
175
|
+
measureDragBasisSize: clientSize => clientSize - H_SCROLLBAR_SIDE_GAP * 2,
|
|
176
|
+
releaseSignal: releaseRef.current?.signal
|
|
178
177
|
});
|
|
179
178
|
};
|
|
180
179
|
return {
|